import lcm from 'lcm';
import _ from 'lodash';
import { BaseConverter } from './BaseConverter.js';
import { ConversionError, ImpossibleSchemaError, InvalidSchemaError } from './errors.js';
import { addKeyIfDefined, copyKeyIfDefined } from './utils.js';
const structuredDataContentTypes = [
    'multipart/form-data',
    'application/json',
    'application/x-www-form-urlencoded',
];
export class SchemaConverter extends BaseConverter {
    constructor(schema, required, path = ['#'], location, contentType, safeParse = false) {
        super(safeParse);
        this.schema = schema;
        this.required = required;
        this.path = path;
        this.location = location;
        this.contentType = contentType;
        this.safeParse = safeParse;
    }
    /**
     * This function converts the `schema` property into a `DataSchemaArray`. Due to
     * the recursive nature of OpenAPI schemas, this conversion happens in two parts:
     *
     * 1. **Reduction**\*: The schema is transformed into its *reduced form*. In this form,
     * the schema and any subschemas are represented by a schema with one property, `oneOf`,
     * whose items are guaranteed NOT to have a `oneOf`, `anyOf`, or `allOf` property.
     *
     * 2. **Conversion**: In this step, we take a schema in its reduced form and convert it
     * into a new data type. This is fairly straightforward, as we just need to convert
     * each element of each `oneOf` schema to a `DataSchema`, and do this for all subschemas.
     *
     * \*We call this step a reduction rather than a conversion because the result is still
     * of the `OpenAPIV3_1.SchemaObject` type.
     *
     * @returns An array of `DataSchema` objects representing all valid schemas
     */
    convert() {
        if (this.schema === undefined) {
            // If the content type can feasibly be interpreted as a file (i.e. if it does NOT start
            // with "application/json" or another structured data format), return a file type.
            if (this.contentType &&
                !structuredDataContentTypes.some((type) => { var _a; return (_a = this.contentType) === null || _a === void 0 ? void 0 : _a.startsWith(type); })) {
                return [{ type: 'file', contentMediaType: this.contentType }];
            }
            this.handleNewError(InvalidSchemaError, this.path, 'schema undefined');
            return [{ type: 'any' }];
        }
        try {
            // TODO(ronan): remove when fully migrated to endpoint type, or don't modify schema in evaluateCompositionsRecursive
            let schema = _.cloneDeep(this.schema);
            // REDUCTION
            schema = this.reduceCompositionsRecursive(this.path, schema);
            // CONVERSION
            return this.convertSchemaRecursive(this.path, schema, this.required);
        }
        catch (error) {
            this.handleExistingError(error, this.path, 'error converting top-level schema');
            return [{ type: 'any' }];
        }
    }
    /**
     * This function should be used to reduce strictly `oneOf` and `anyOf` compositions.
     *
     * @param schemaArray `schema.allOf` or `schema.oneOf`
     * @returns a schema array equivalent to the `schemaArray` argument, but in reduced form
     */
    reduceOptionsCompositions(path, schemaArray) {
        const evaluatedArray = schemaArray.flatMap((subschema, i) => {
            var _a;
            try {
                return (_a = this.reduceCompositionsRecursive([...path, i.toString()], subschema).oneOf) !== null && _a !== void 0 ? _a : [];
            }
            catch (error) {
                if (error instanceof ImpossibleSchemaError) {
                    return [];
                }
                else {
                    throw error;
                }
            }
        });
        if (evaluatedArray.length === 0) {
            throw new ImpossibleSchemaError(path, 'no valid options in schema:', JSON.stringify(schemaArray, undefined, 2));
        }
        return evaluatedArray;
    }
    reduceCompositionsRecursive(path, schema) {
        // reduce compositions first; we are currently ignoring `not`
        if (schema.oneOf && schema.oneOf.length > 0) {
            schema.oneOf = this.reduceOptionsCompositions([...path, 'oneOf'], schema.oneOf);
        }
        else {
            schema.oneOf = [];
        }
        if (schema.anyOf && schema.anyOf.length > 0) {
            schema.anyOf = this.reduceOptionsCompositions([...path, 'anyOf'], schema.anyOf);
        }
        if (schema.allOf && schema.allOf.length > 0) {
            const totalAllOfObj = schema.allOf
                .map((subschema, i) => this.reduceCompositionsRecursive([...path, 'allOf', i.toString()], subschema))
                .reduce((schema1, schema2, i) => this.combineReducedSchemas([...path, 'allOf', i.toString()], schema1, schema2), {
                oneOf: [],
            });
            schema.oneOf = this.multiplySchemaArrays(path, schema.oneOf, totalAllOfObj.oneOf);
        }
        // reduce subschemas, if present
        if (schema.properties) {
            for (const key in schema.properties) {
                const subschema = schema.properties[key];
                // remove readOnly and writeOnly properties BEFORE we combine anything
                if ((subschema.readOnly && this.location === 'request') ||
                    (subschema.writeOnly && this.location === 'response')) {
                    delete schema.properties[key];
                    continue;
                }
                const propertyPath = [...path, 'properties', key];
                try {
                    schema.properties[key] = this.reduceCompositionsRecursive(propertyPath, subschema);
                }
                catch (error) {
                    this.handleExistingError(error, propertyPath, 'error reducing property schema');
                    schema.properties[key] = { oneOf: [{}] }; // an "any" schema
                }
            }
        }
        if ('items' in schema && schema.items) {
            const itemsPath = [...path, 'items'];
            try {
                schema.items = this.reduceCompositionsRecursive(itemsPath, schema.items);
            }
            catch (error) {
                this.handleExistingError(error, itemsPath, 'error reducing items schema');
                schema.items = { oneOf: [{}] }; // an "any" schema
            }
        }
        if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
            const addlPropsPath = [...path, 'additionalProperties'];
            try {
                schema.additionalProperties = this.reduceCompositionsRecursive(addlPropsPath, schema.additionalProperties);
            }
            catch (error) {
                if (error instanceof ImpossibleSchemaError) {
                    // if additionalProperties schema is impossible, rather than error, just disallow additionalProperties
                    schema.additionalProperties = false;
                }
                else {
                    this.handleExistingError(error, addlPropsPath, 'error reducing additionalProperties schema');
                    schema.additionalProperties = true; // an "any" schema
                }
            }
        }
        if (schema.anyOf && schema.anyOf.length > 0) {
            schema.oneOf = this.multiplySchemaArrays(path, schema.oneOf, schema.anyOf);
        }
        const topLevelSchemaArray = this.generateTopLevelSchemaArray(schema);
        return { oneOf: this.multiplySchemaArrays(path, schema.oneOf, topLevelSchemaArray) };
    }
    generateTopLevelSchemaArray(schema) {
        if (schema.nullable) {
            const typedSchema = Object.assign({}, schema);
            delete typedSchema.oneOf;
            delete typedSchema.nullable;
            const nullSchema = Object.assign({}, schema);
            delete nullSchema.oneOf;
            delete nullSchema.nullable;
            nullSchema.type = 'null';
            return [typedSchema, nullSchema];
        }
        if (Array.isArray(schema.type)) {
            if (schema.type.length === 0) {
                const topLevelSchema = Object.assign({}, schema);
                delete topLevelSchema.oneOf;
                delete topLevelSchema.type;
                return [topLevelSchema];
            }
            return schema.type.map((typeString) => {
                const topLevelSchema = Object.assign({}, schema);
                delete topLevelSchema.oneOf;
                topLevelSchema.type = typeString;
                return topLevelSchema;
            });
        }
        const topLevelSchema = Object.assign({}, schema);
        delete topLevelSchema.oneOf;
        return [topLevelSchema];
    }
    /**
     * Given two arrays representing schema options, return an array representing schema options that satisfy one element in both arrays.
     *
     * It is helpful to think of each array as a union of all the schemas in the array. This function can then be thought of as taking
     * the intersection of the two union types.
     *
     * Used in the Reduction step.
     *
     * @param a first array of schema options
     * @param b second array of schema options
     * @returns array of schemas that satisfy both arrays
     */
    multiplySchemaArrays(path, a, b) {
        if (a.length === 0 && b.length === 0) {
            return [{}];
        }
        if (a.length === 0) {
            return b;
        }
        if (b.length === 0) {
            return a;
        }
        const product = a.flatMap((schema1) => {
            return b.flatMap((schema2) => {
                try {
                    const combinedSchema = this.combineTopLevelSchemas(path, schema1, schema2);
                    return [combinedSchema];
                }
                catch (error) {
                    if (error instanceof ImpossibleSchemaError) {
                        return [];
                    }
                    else {
                        throw error;
                    }
                }
            });
        });
        if (product.length === 0) {
            throw new ImpossibleSchemaError(path, 'impossible schema combination:', 'schema array 1:', JSON.stringify(a, undefined, 2), 'schema array 2:', JSON.stringify(b, undefined, 2));
        }
        return product;
    }
    combineReducedSchemas(path, schema1, schema2) {
        var _a, _b;
        return {
            oneOf: this.multiplySchemaArrays(path, ((_a = schema1.oneOf) !== null && _a !== void 0 ? _a : []), ((_b = schema2.oneOf) !== null && _b !== void 0 ? _b : [])),
        };
    }
    combineTopLevelSchemas(path, schema1, schema2) {
        var _a, _b;
        let type1 = schema1.type;
        let type2 = schema2.type;
        // don't throw an error if number type is being constricted
        if (type1 === 'integer' && type2 === 'number') {
            type2 = 'integer';
        }
        else if (type1 === 'number' && type2 === 'integer') {
            type1 = 'integer';
        }
        if (type1 && type2 && type1 !== type2) {
            throw new ImpossibleSchemaError(path, `mismatched type in composition: "${type1}" "${type2}"`);
        }
        for (const schema of [schema1, schema2]) {
            if (typeof schema.exclusiveMaximum === 'number') {
                if (schema.maximum === undefined || schema.maximum >= schema.exclusiveMaximum) {
                    schema.maximum = schema.exclusiveMaximum;
                    schema.exclusiveMaximum = true;
                }
                else {
                    schema.exclusiveMaximum = undefined;
                }
            }
            if (typeof schema.exclusiveMinimum === 'number') {
                if (schema.minimum === undefined || schema.minimum <= schema.exclusiveMinimum) {
                    schema.minimum = schema.exclusiveMinimum;
                    schema.exclusiveMinimum = true;
                }
                else {
                    schema.exclusiveMinimum = undefined;
                }
            }
        }
        const combinedSchema = {
            title: takeLast(schema1, schema2, 'title'),
            description: takeLast(schema1, schema2, 'description'),
            format: takeLast(schema1, schema2, 'format'),
            multipleOf: combine(schema1, schema2, 'multipleOf', lcm),
            maximum: combine(schema1, schema2, 'maximum', Math.min),
            minimum: combine(schema1, schema2, 'minimum', Math.max),
            maxLength: combine(schema1, schema2, 'maxLength', Math.min),
            minLength: combine(schema1, schema2, 'minLength', Math.max),
            maxItems: combine(schema1, schema2, 'maxItems', Math.min),
            minItems: combine(schema1, schema2, 'minItems', Math.max),
            maxProperties: combine(schema1, schema2, 'maxProperties', Math.min),
            minProperties: combine(schema1, schema2, 'minProperties', Math.max),
            required: combine(schema1, schema2, 'required', (a, b) => b.concat(a.filter((value) => !b.includes(value)))),
            enum: combine(schema1, schema2, 'enum', (a, b) => b.filter((value) => a.includes(value))),
            readOnly: schema1.readOnly && schema2.readOnly,
            writeOnly: schema1.writeOnly && schema2.writeOnly,
            deprecated: schema1.deprecated && schema2.deprecated,
        };
        combinedSchema.exclusiveMaximum =
            (schema1.maximum === combinedSchema.maximum ? schema1.exclusiveMaximum : undefined) ||
                (schema2.maximum === combinedSchema.maximum ? schema2.exclusiveMaximum : undefined);
        combinedSchema.exclusiveMinimum =
            (schema1.minimum === combinedSchema.minimum ? schema1.exclusiveMinimum : undefined) ||
                (schema2.minimum === combinedSchema.minimum ? schema2.exclusiveMinimum : undefined);
        // don't use coalesce operator, since null is a valid example
        const example1 = ((_a = schema1.examples) === null || _a === void 0 ? void 0 : _a[0]) !== undefined ? schema1.examples[0] : schema1.example;
        const example2 = ((_b = schema2.examples) === null || _b === void 0 ? void 0 : _b[0]) !== undefined ? schema2.examples[0] : schema2.example;
        if (example1 && example2 && typeof example1 === 'object' && typeof example2 === 'object') {
            combinedSchema.example = Object.assign(Object.assign({}, example1), example2);
        }
        else {
            // don't use coalesce operator, since null is a valid example
            combinedSchema.example = example2 !== undefined ? example2 : example1;
        }
        const type = type1 !== null && type1 !== void 0 ? type1 : type2;
        if (type === 'array') {
            return Object.assign({ type, items: this.combineReducedSchemas([...path, 'items'], 'items' in schema1 && schema1.items ? schema1.items : {}, 'items' in schema2 && schema2.items ? schema2.items : {}) }, combinedSchema);
        }
        if (schema1.properties && schema2.properties) {
            const combinedProperties = Object.assign({}, schema1.properties);
            Object.entries(schema2.properties).forEach(([property, schema]) => {
                const schema1Property = combinedProperties[property];
                if (schema1Property) {
                    combinedProperties[property] = this.combineReducedSchemas([...path, 'properties', property], schema1Property, schema);
                }
                else {
                    combinedProperties[property] = schema;
                }
            });
            combinedSchema.properties = combinedProperties;
        }
        else if (schema1.properties || schema2.properties) {
            combinedSchema.properties = Object.assign(Object.assign({}, schema1.properties), schema2.properties);
        }
        if (schema1.additionalProperties === false || schema2.additionalProperties === false) {
            combinedSchema.additionalProperties = false;
        }
        else if (schema1.additionalProperties &&
            typeof schema1.additionalProperties === 'object' &&
            schema2.additionalProperties &&
            typeof schema2.additionalProperties === 'object') {
            combinedSchema.additionalProperties = this.combineReducedSchemas([...path, 'additionalProperties'], schema1.additionalProperties, schema2.additionalProperties);
        }
        else if (schema1.additionalProperties && typeof schema1.additionalProperties === 'object') {
            combinedSchema.additionalProperties = schema1.additionalProperties;
        }
        else if (schema2.additionalProperties && typeof schema2.additionalProperties === 'object') {
            combinedSchema.additionalProperties = schema2.additionalProperties;
        }
        return Object.assign({ type }, combinedSchema);
    }
    convertSchemaRecursive(path, schema, required) {
        if (schema.oneOf === undefined || schema.oneOf.length === 0) {
            throw new ConversionError(path, 'missing schema definition');
        }
        const schemaArray = schema.oneOf.map((schema) => {
            const sharedProps = {};
            addKeyIfDefined('required', required, sharedProps);
            copyKeyIfDefined('title', schema, sharedProps);
            copyKeyIfDefined('description', schema, sharedProps);
            copyKeyIfDefined('readOnly', schema, sharedProps);
            copyKeyIfDefined('writeOnly', schema, sharedProps);
            copyKeyIfDefined('deprecated', schema, sharedProps);
            if (schema.type === undefined) {
                const inferredType = inferType(schema);
                if (inferredType === undefined) {
                    return Object.assign({ type: 'any' }, sharedProps);
                }
                schema.type = inferredType;
            }
            switch (schema.type) {
                case 'boolean':
                    const booleanProps = sharedProps;
                    copyKeyIfDefined('default', schema, booleanProps);
                    copyExampleIfDefined(schema, booleanProps);
                    return Object.assign({ type: schema.type }, booleanProps);
                case 'number':
                case 'integer':
                    if (schema.enum) {
                        const numberEnumProps = sharedProps;
                        copyKeyIfDefined('default', schema, numberEnumProps);
                        copyExampleIfDefined(schema, numberEnumProps);
                        return Object.assign({ type: schema.type === 'number' ? 'enum<number>' : 'enum<integer>', enum: schema.enum }, numberEnumProps);
                    }
                    const numberProps = sharedProps;
                    copyKeyIfDefined('multipleOf', schema, numberProps);
                    copyKeyIfDefined('maximum', schema, numberProps);
                    copyKeyIfDefined('exclusiveMaximum', schema, numberProps);
                    copyKeyIfDefined('minimum', schema, numberProps);
                    copyKeyIfDefined('exclusiveMinimum', schema, numberProps);
                    copyKeyIfDefined('default', schema, numberProps);
                    copyExampleIfDefined(schema, numberProps);
                    return Object.assign({ type: schema.type }, numberProps);
                case 'string':
                    if (schema.enum) {
                        const stringEnumProps = sharedProps;
                        copyKeyIfDefined('default', schema, stringEnumProps);
                        copyExampleIfDefined(schema, stringEnumProps);
                        return Object.assign({ type: 'enum<string>', enum: schema.enum }, stringEnumProps);
                    }
                    const stringProps = sharedProps;
                    copyKeyIfDefined('format', schema, stringProps);
                    copyKeyIfDefined('pattern', schema, stringProps);
                    copyKeyIfDefined('maxLength', schema, stringProps);
                    copyKeyIfDefined('minLength', schema, stringProps);
                    copyKeyIfDefined('default', schema, stringProps);
                    copyExampleIfDefined(schema, stringProps);
                    return Object.assign({ type: schema.type }, stringProps);
                case 'array':
                    const arrayProps = sharedProps;
                    let items = undefined;
                    const itemsPath = [...path, 'items'];
                    try {
                        items =
                            // validator allows items to be null
                            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
                            typeof schema.items === 'object' && schema.items != null
                                ? this.convertSchemaRecursive(itemsPath, schema.items)
                                : [{ type: 'any' }];
                    }
                    catch (error) {
                        this.handleExistingError(error, itemsPath, 'error converting items schema');
                        items = [{ type: 'any' }];
                    }
                    copyKeyIfDefined('maxItems', schema, arrayProps);
                    copyKeyIfDefined('minItems', schema, arrayProps);
                    copyKeyIfDefined('uniqueItems', schema, arrayProps);
                    copyKeyIfDefined('default', schema, arrayProps);
                    copyExampleIfDefined(schema, arrayProps);
                    return Object.assign({ type: schema.type, items }, arrayProps);
                case 'object':
                    const properties = this.convertProperties([...path, 'properties'], schema.properties, schema.required);
                    let additionalProperties = undefined;
                    const addlPropsPath = [...path, 'additionalProperties'];
                    try {
                        additionalProperties =
                            // validator allows additionalProperties to be null
                            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
                            typeof schema.additionalProperties === 'object' && schema.additionalProperties != null
                                ? this.convertSchemaRecursive(addlPropsPath, schema.additionalProperties)
                                : schema.additionalProperties;
                    }
                    catch (error) {
                        this.handleExistingError(error, addlPropsPath, 'error converting additionalProperties schema');
                        // don't need to assign additionalProperties because undefined = "any"
                    }
                    const objectProperties = sharedProps;
                    addKeyIfDefined('additionalProperties', additionalProperties, objectProperties);
                    copyKeyIfDefined('maxProperties', schema, objectProperties);
                    copyKeyIfDefined('minProperties', schema, objectProperties);
                    copyKeyIfDefined('default', schema, objectProperties);
                    copyExampleIfDefined(schema, objectProperties);
                    return Object.assign({ type: schema.type, properties }, objectProperties);
                case 'null':
                    const nullProps = sharedProps;
                    copyKeyIfDefined('default', schema, nullProps);
                    copyExampleIfDefined(schema, nullProps);
                    return Object.assign({ type: schema.type }, nullProps);
                default:
                    throw new InvalidSchemaError(path, `invalid schema type: ${schema.type}`);
            }
        });
        if (!schemaArray[0]) {
            throw new ConversionError(path, 'missing schema definition in position 0');
        }
        // must unpack first element to satisfy type
        return [schemaArray[0], ...schemaArray.slice(1)];
    }
    convertProperties(path, properties, required) {
        if (properties === undefined) {
            return {};
        }
        const newEntries = Object.entries(properties).map(([name, schema]) => {
            const propPath = [...path, name];
            let convertedSchema;
            try {
                convertedSchema = this.convertSchemaRecursive(propPath, schema, (required === null || required === void 0 ? void 0 : required.includes(name)) ? true : undefined);
            }
            catch (error) {
                this.handleExistingError(error, propPath, 'error converting property schema');
                convertedSchema = [{ type: 'any' }];
            }
            return [name, convertedSchema];
        });
        return Object.fromEntries(newEntries);
    }
    static convert({ schema, required, path, location, contentType, safeParse, }) {
        return new SchemaConverter(schema, required, path, location, contentType, safeParse).convert();
    }
}
const copyExampleIfDefined = (source, destination) => {
    var _a;
    const example = ((_a = source.examples) === null || _a === void 0 ? void 0 : _a[0]) !== undefined ? source.examples[0] : source.example;
    if (example !== undefined) {
        destination.example = example;
    }
};
const takeLast = (schema1, schema2, key) => {
    var _a;
    return (_a = schema2[key]) !== null && _a !== void 0 ? _a : schema1[key];
};
const combine = (schema1, schema2, key, transform) => {
    var _a;
    return schema1[key] !== undefined && schema2[key] !== undefined
        ? transform(schema1[key], schema2[key])
        : (_a = schema1[key]) !== null && _a !== void 0 ? _a : schema2[key];
};
/**
 * Given an OpenAPI 3.1 schema, this function will attempt to determine the schema type
 * based on the properties present in the schema. This is useful for assigning types to
 * schemas that are missing a type.
 *
 * For example, if a schema has no type but has `schema.properties`, we can infer the
 * intended type is `object`.
 *
 * Used in the Conversion step.
 *
 * @param schema
 * @returns if exactly one type can be inferred, the string corresponding to that type; otherwise `undefined`
 */
const inferType = (schema) => {
    var _a, _b;
    let type = undefined;
    if (schema.format !== undefined ||
        schema.pattern !== undefined ||
        schema.minLength !== undefined ||
        schema.maxLength !== undefined ||
        ((_a = schema.enum) === null || _a === void 0 ? void 0 : _a.every((option) => typeof option === 'string'))) {
        type = 'string';
    }
    if (schema.multipleOf !== undefined ||
        schema.minimum !== undefined ||
        schema.maximum !== undefined ||
        schema.exclusiveMinimum !== undefined ||
        schema.exclusiveMaximum !== undefined ||
        ((_b = schema.enum) === null || _b === void 0 ? void 0 : _b.every((option) => typeof option === 'number'))) {
        if (type !== undefined) {
            return undefined;
        }
        type = 'number'; // less specific than 'integer'
    }
    if (('items' in schema && schema.items !== undefined) ||
        schema.minItems !== undefined ||
        schema.maxItems !== undefined ||
        schema.uniqueItems !== undefined) {
        if (type !== undefined) {
            return undefined;
        }
        type = 'array';
    }
    if (schema.additionalProperties !== undefined ||
        schema.properties !== undefined ||
        schema.minProperties !== undefined ||
        schema.maxProperties !== undefined) {
        if (type !== undefined) {
            return undefined;
        }
        type = 'object';
    }
    return type;
};
